// Package nodejs provides backends for Node.js using Yarn, PNPM and NPM.
package nodejs

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/url"
	"os"
	"os/exec"
	"regexp"
	"strings"

	"github.com/hashicorp/go-version"
	"github.com/replit/upm/internal/api"
	"github.com/replit/upm/internal/nix"
	"github.com/replit/upm/internal/util"
	"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
	"gopkg.in/yaml.v2"
)

// npmSearchResults represents the data we get from the NPM API when
// doing a search.
//
// See https://github.com/npm/registry/blob/5db1bb329f554454467531a3e1bae5e97da160df/docs/REGISTRY-API.md
// for documentation on the format.
type npmSearchResults struct {
	Objects []struct {
		Package struct {
			Name        string `json:"name"`
			Version     string `json:"version"`
			Description string `json:"description"`
			Links       struct {
				Homepage   packageLink `json:"homepage"`
				Repository packageLink `json:"repository"`
				Bugs       packageLink `json:"bugs"`
			} `json:"links"`
			Author struct {
				Username string `json:"username"`
				Email    string `json:"email"`
			} `json:"author"`
		} `json:"package"`
	} `json:"objects"`
}

type packageLink struct{ URL string }

var _ json.Unmarshaler = (*packageLink)(nil)

// UnmarshalJSON implements json.Unmarshaler.
//
// This supports link represented as a single string,
// list of strings (taking the first one), or an object
// with the field "url".
func (link *packageLink) UnmarshalJSON(data []byte) error {
	var str string
	if err := json.Unmarshal(data, &str); err == nil {
		link.URL = str
		return nil
	}

	var obj map[string]interface{}
	if err := json.Unmarshal(data, &obj); err == nil {
		if url, ok := obj["url"].(string); ok {
			link.URL = url
		}
		return nil
	}

	var slice []string
	if err := json.Unmarshal(data, &slice); err == nil {
		if len(slice) == 0 {
			link.URL = ""
			return nil
		}
		link.URL = slice[0]
		return nil
	}

	return errors.New("expected link to be a string, list of strings, or an object")
}

// npmInfoResult represents the data we get from the NPM API when
// doing a single package lookup.
//
// See https://github.com/npm/registry/blob/5db1bb329f554454467531a3e1bae5e97da160df/docs/responses/package-metadata.md
// for documentation on the format.
//
// ---
//
// note: the format isn't 100% validated to the type that's described in the link above.
// > Historically no validation has been performed on those fields; they are generated by parsing
// > user-provided data in package.json at publication time.
//
// which means that we have to deserialize the union of the type described in the document, and the
// type of a package.json file: https://docs.npmjs.com/cli/v10/configuring-npm/package-json
type npmInfoResult struct {
	Name        string                 `json:"name"`
	Versions    map[string]interface{} `json:"versions"`
	Author      packageJsonPerson      `json:"author"`
	Bugs        packageLink            `json:"bugs"`
	Description string                 `json:"description"`
	Homepage    packageLink            `json:"homepage"`
	License     string                 `json:"license"`
	Repository  packageLink            `json:"repository"`
}

type packageJsonPerson struct {
	Name  string
	Email string
	URL   string
}

var packageJsonPersonStringRegexp *regexp.Regexp

func (person *packageJsonPerson) UnmarshalJSON(data []byte) error {
	var str string
	if err := json.Unmarshal(data, &str); err == nil {
		if packageJsonPersonStringRegexp == nil {
			// parses the form: name <email> (url)
			// eg: John Doe <joe.dohn@example.zip> (ftp://C:\Nix\Store)
			// any of the 3 are optional
			packageJsonPersonStringRegexp = regexp.MustCompile(`([^\<\(]*)?\s*(\<.*\>)?\s*(\(.*\))?`)
		}

		captures := packageJsonPersonStringRegexp.FindStringSubmatch(str)
		if len(captures) == 4 {
			if name := captures[1]; name != "" {
				person.Name = name
			}

			if email := captures[2]; email != "" {
				person.Email = email
			}

			if url := captures[3]; url != "" {
				person.URL = url
			}

			return nil
		}

		// in case the regex fails to parse
		person.URL = str
		return nil
	}

	var obj map[string]interface{}
	if err := json.Unmarshal(data, &obj); err == nil {
		if name, ok := obj["name"].(string); ok {
			person.Name = name
		}

		if email, ok := obj["email"].(string); ok {
			person.Email = email
		}

		if url, ok := obj["url"].(string); ok {
			person.URL = url
		}

		return nil
	}

	return errors.New("expected person in package.json to be a string or an object")
}

// packageJSON represents the relevant data in a package.json file.
type packageJSON struct {
	Dependencies    map[string]string `json:"dependencies"`
	DevDependencies map[string]string `json:"devDependencies"`
}

// packageLockJSON represents the relevant data in a package-lock.json
// file.
type packageLockJSON struct {
	LockfileVersion int `json:"lockfileVersion"`
	Dependencies    map[string]struct {
		Version string `json:"version"`
	} `json:"dependencies"`
	Packages map[string]struct {
		Version string `json:"version"`
	} `json:"packages"`
}

// nodejsPatterns is the FilenamePatterns value for NodejsBackend.
var nodejsPatterns = []string{"*.js", "*.ts", "*.jsx", "*.tsx", "*.mjs", "*.cjs"}

func bunIsAvailable() bool {
	_, err := exec.LookPath("bun")
	return err == nil
}

func pnpmIsAvailable() bool {
	_, err := exec.LookPath("pnpm")
	return err == nil
}

func yarnIsAvailable() bool {
	_, err := exec.LookPath("yarn")
	return err == nil
}

func npmIsAvailable() bool {
	_, err := exec.LookPath("npm")
	return err == nil
}

// nodejsSearch implements Search for nodejs-yarn, nodejs-pnpm and nodejs-npm.
func nodejsSearch(query string) []api.PkgInfo {
	// Special case: if search query is only one character, the
	// API doesn't return any results. The web interface to NPM
	// deals with this by just jumping to the package with that
	// exact name, or returning a 404 if there isn't one. Let's
	// try to do something similar.
	if len(query) == 1 {
		info := nodejsInfo(api.PkgName(query))
		if info.Name != "" {
			return []api.PkgInfo{info}
		} else {
			return []api.PkgInfo{}
		}
	}

	endpoint := "https://registry.npmjs.org/-/v1/search"
	queryParams := "?text=" + url.QueryEscape(query)

	resp, err := api.HttpClient.Get(endpoint + queryParams)
	if err != nil {
		util.DieNetwork("NPM registry: %s", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == 429 {
		util.DieProtocol("Rate limited")
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		util.DieProtocol("NPM registry: %s", err)
	}

	var npmResults npmSearchResults
	if err := json.Unmarshal(body, &npmResults); err != nil {
		util.DieProtocol("NPM registry: %s", err)
	}

	results := make([]api.PkgInfo, len(npmResults.Objects))
	for i := range npmResults.Objects {
		p := npmResults.Objects[i].Package
		results[i] = api.PkgInfo{
			Name:          p.Name,
			Description:   p.Description,
			Version:       p.Version,
			HomepageURL:   p.Links.Homepage.URL,
			SourceCodeURL: p.Links.Repository.URL,
			BugTrackerURL: p.Links.Bugs.URL,
			Author: util.AuthorInfo{
				Name:  p.Author.Username,
				Email: p.Author.Email,
			}.String(),
		}
	}
	return results
}

// nodejsInfo implements Info for nodejs-yarn, nodejs-pnpm and nodejs-npm.
func nodejsInfo(name api.PkgName) api.PkgInfo {
	endpoint := "https://registry.npmjs.org"
	path := "/" + url.QueryEscape(string(name))

	resp, err := api.HttpClient.Get(endpoint + path)
	if err != nil {
		util.DieNetwork("NPM registry: %s", err)
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case 200:
		break
	case 404:
		return api.PkgInfo{}
	default:
		util.DieNetwork("NPM registry: HTTP status %d", resp.StatusCode)
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		util.DieProtocol("NPM registry: could not read response: %s", err)
	}

	var npmInfo npmInfoResult
	if err := json.Unmarshal(body, &npmInfo); err != nil {
		util.DieProtocol("NPM registry: %s", err)
	}

	lastVersionStr := ""
	if len(npmInfo.Versions) > 0 {
		var lastVersion *version.Version = nil
		for versionStr := range npmInfo.Versions {
			version, err := version.NewVersion(versionStr)
			if err != nil {
				continue
			}

			if version.Prerelease() != "" {
				continue
			}

			if lastVersion == nil || version.GreaterThan(lastVersion) {
				lastVersion = version
			}
		}
		if lastVersion != nil {
			lastVersionStr = lastVersion.String()
		}
	}

	return api.PkgInfo{
		Name:          npmInfo.Name,
		Description:   npmInfo.Description,
		Version:       lastVersionStr,
		HomepageURL:   npmInfo.Homepage.URL,
		SourceCodeURL: npmInfo.Repository.URL,
		BugTrackerURL: npmInfo.Bugs.URL,
		Author: util.AuthorInfo{
			Name:  npmInfo.Author.Name,
			Email: npmInfo.Author.Email,
			URL:   npmInfo.Author.URL,
		}.String(),
		License: npmInfo.License,
	}
}

// nodejsListSpecfile implements ListSpecfile for nodejs-yarn, nodejs-pnpm and
// nodejs-npm.
func nodejsListSpecfile(mergeAllGroups bool) map[api.PkgName]api.PkgSpec {
	contentsB, err := os.ReadFile("package.json")
	if err != nil {
		util.DieIO("package.json: %s", err)
	}
	var cfg packageJSON
	if err := json.Unmarshal(contentsB, &cfg); err != nil {
		util.DieProtocol("package.json: %s", err)
	}
	pkgs := map[api.PkgName]api.PkgSpec{}
	for nameStr, specStr := range cfg.Dependencies {
		pkgs[api.PkgName(nameStr)] = api.PkgSpec(specStr)
	}
	for nameStr, specStr := range cfg.DevDependencies {
		pkgs[api.PkgName(nameStr)] = api.PkgSpec(specStr)
	}
	return pkgs
}

// nodejsGuessRegexps is the value of GuessRegexps for nodejs-yarn, nodejs-pnpm and
// nodejs-npm.
var nodejsGuessRegexps = util.Regexps([]string{
	// import defaultExport from "module-name";
	// import * as name from "module-name";
	// import { export } from "module-name";
	`(?m)from\s*['"]([^'"]+)['"]\s*;?\s*$`,
	// import "module-name";
	`(?m)import\s*['"]([^'"]+)['"]\s*;?\s*$`,
	// const mod = import("module-name")
	// const mod = require("module-name")
	`(?m)(?:require|import)\s*\(\s*['"]([^'"{}]+)['"]\s*\)`,
})

var nodeIgnorePathSegments = map[string]bool{
	"node_modules": true,
}

var jsPathGlobs = []string{
	"*.js",
	"*.jsx",
	"*.mjs",
	"*.cjs",
}

var tsPathGlobs = []string{
	"*.ts",
}

var tsxPathGlobs = []string{
	"*.tsx",
}

func commonIsActive(lockfile string) bool {
	_, err := os.Stat(lockfile)
	return !os.IsNotExist(err)
}

// NodejsYarnBackend is a UPM backend for Node.js that uses [Yarn](https://yarnpkg.com/).
var NodejsYarnBackend = api.LanguageBackend{
	Name:        "nodejs-yarn",
	Specfile:    "package.json",
	Lockfile:    "yarn.lock",
	IsAvailable: yarnIsAvailable,
	IsActive: func() bool {
		return commonIsActive("yarn.lock")
	},
	FilenamePatterns: nodejsPatterns,
	Quirks: api.QuirksAddRemoveAlsoLocks |
		api.QuirksAddRemoveAlsoInstalls |
		api.QuirksLockAlsoInstalls |
		api.QuirkRemoveNeedsLockfile,
	GetPackageDir: func() string {
		return "node_modules"
	},
	Search: nodejsSearch,
	Info:   nodejsInfo,
	Add: func(ctx context.Context, pkgs map[api.PkgName]api.PkgCoordinates, projectName string, skipAudit bool) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "yarn (init) add")
		defer span.Finish()
		if !util.Exists("package.json") {
			util.RunCmd([]string{"yarn", "init", "-y"})
		}
		cmd := []string{"yarn", "add"}
		// Note: Yarn (classic/v1) doesn't support --no-audit flag
		for name, coords := range pkgs {
			name := string(name)
			if found, ok := moduleToYarnpkgPackageAliases[name]; ok {
				delete(pkgs, api.PkgName(name))
				name = found
				coords.Name = found
				pkgs[api.PkgName(name)] = coords
			}
			arg := name
			if coords.Spec != "" {
				arg += "@" + string(coords.Spec)
			}
			cmd = append(cmd, arg)
		}
		util.RunCmd(cmd)
	},
	Remove: func(ctx context.Context, pkgs map[api.PkgName]bool) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "yarn remove")
		defer span.Finish()

		cmd := []string{"yarn", "remove"}
		for name := range pkgs {
			cmd = append(cmd, string(name))
		}
		util.RunCmd(cmd)
	},
	Lock: func(ctx context.Context) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "yarn install")
		defer span.Finish()
		util.RunCmd([]string{"yarn", "install"})
	},
	Install: func(ctx context.Context) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "yarn install")
		defer span.Finish()
		util.RunCmd([]string{"yarn", "install"})
	},
	ListSpecfile: nodejsListSpecfile,
	ListLockfile: func() map[api.PkgName]api.PkgVersion {
		contentsB, err := os.ReadFile("yarn.lock")
		if err != nil {
			util.DieIO("yarn.lock: %s", err)
		}
		contents := string(contentsB)
		r := regexp.MustCompile(`(?m)^"?((?:@[^@ \n]+\/)?[^@ \n]+).+:\n  version "(.+)"$`)
		pkgs := map[api.PkgName]api.PkgVersion{}
		for _, match := range r.FindAllStringSubmatch(contents, -1) {
			name := api.PkgName(match[1])
			version := api.PkgVersion(match[2])
			pkgs[name] = version
		}
		return pkgs
	},
	// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
	GuessRegexps:                       nodejsGuessRegexps,
	Guess:                              nodejsGuess,
	InstallReplitNixSystemDependencies: nix.DefaultInstallReplitNixSystemDependencies,
	InstallDotReplitSystemDependencies: nix.DefaultInstallDotReplitSystemDependencies,
}

// NodejsPNPMBackend is a UPM backend for Node.js that uses [pnpm](https://pnpm.io/).
var NodejsPNPMBackend = api.LanguageBackend{
	Name:        "nodejs-pnpm",
	Specfile:    "package.json",
	Lockfile:    "pnpm-lock.yaml",
	IsAvailable: pnpmIsAvailable,
	IsActive: func() bool {
		return commonIsActive("pnpm-lock.yaml")
	},
	FilenamePatterns: nodejsPatterns,
	Quirks: api.QuirksAddRemoveAlsoLocks |
		api.QuirksAddRemoveAlsoInstalls |
		api.QuirksLockAlsoInstalls,
	GetPackageDir: func() string {
		return "node_modules"
	},
	Search: nodejsSearch,
	Info:   nodejsInfo,
	Add: func(ctx context.Context, pkgs map[api.PkgName]api.PkgCoordinates, projectName string, skipAudit bool) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "pnpm (init) add")
		defer span.Finish()
		if !util.Exists("package.json") {
			util.RunCmd([]string{"pnpm", "init"})
		}
		cmd := []string{"pnpm", "add"}
		for name, coords := range pkgs {
			name := string(name)
			if found, ok := moduleToNpmjsPackageAliases[name]; ok {
				delete(pkgs, api.PkgName(name))
				name = found
				coords.Name = found
				pkgs[api.PkgName(name)] = coords
			}
			arg := name
			if coords.Spec != "" {
				arg += "@" + string(coords.Spec)
			}
			cmd = append(cmd, arg)
		}
		util.RunCmd(cmd)
	},
	Remove: func(ctx context.Context, pkgs map[api.PkgName]bool) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "pnpm remove")
		defer span.Finish()
		cmd := []string{"pnpm", "remove"}
		for name := range pkgs {
			cmd = append(cmd, string(name))
		}
		util.RunCmd(cmd)
	},
	Lock: func(ctx context.Context) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "pnpm install")
		defer span.Finish()
		util.RunCmd([]string{"pnpm", "install"})
	},
	Install: func(ctx context.Context) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "pnpm install")
		defer span.Finish()
		util.RunCmd([]string{"pnpm", "install"})
	},
	ListSpecfile: nodejsListSpecfile,
	ListLockfile: func() map[api.PkgName]api.PkgVersion {
		lockfileBytes, err := os.ReadFile("pnpm-lock.yaml")
		if err != nil {
			util.DieIO("pnpm-lock.yaml: %s", err)
		}

		lockfile := map[string]interface{}{}
		err = yaml.Unmarshal(lockfileBytes, &lockfile)
		if err != nil {
			util.DieProtocol("pnpm-lock.yaml: %s", err)
		}

		lockfileVersion := strings.Split(lockfile["lockfileVersion"].(string), ".")
		lvMajor := lockfileVersion[0]

		pkgs := map[api.PkgName]api.PkgVersion{}
		switch lvMajor {
		case "6":
			dependencies, ok := lockfile["dependencies"]
			if !ok {
				return pkgs
			}

			for pkgName, pkgInfo := range dependencies.(map[interface{}]interface{}) {
				pkgs[api.PkgName(pkgName.(string))] = api.PkgVersion(pkgInfo.(map[interface{}]interface{})["version"].(string))
			}

		default:
			util.DieInitializationError("pnpm-lock.yaml: unsupported lockfile version %s", lockfileVersion)
		}

		return pkgs
	},
	// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
	GuessRegexps:                       nodejsGuessRegexps,
	Guess:                              nodejsGuess,
	InstallReplitNixSystemDependencies: nix.DefaultInstallReplitNixSystemDependencies,
	InstallDotReplitSystemDependencies: nix.DefaultInstallDotReplitSystemDependencies,
}

// NodejsNPMBackend is a UPM backend for Node.js that uses [NPM](https://npmjs.com/).
var NodejsNPMBackend = api.LanguageBackend{
	Name:        "nodejs-npm",
	Specfile:    "package.json",
	Lockfile:    "package-lock.json",
	IsAvailable: npmIsAvailable,
	IsActive: func() bool {
		return commonIsActive("package-lock.json")
	},
	FilenamePatterns: nodejsPatterns,
	Quirks: api.QuirksAddRemoveAlsoLocks |
		api.QuirksAddRemoveAlsoInstalls |
		api.QuirksLockAlsoInstalls,
	GetPackageDir: func() string {
		return "node_modules"
	},
	Search: nodejsSearch,
	Info:   nodejsInfo,
	Add: func(ctx context.Context, pkgs map[api.PkgName]api.PkgCoordinates, projectName string, skipAudit bool) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "npm (init) install")
		defer span.Finish()
		if !util.Exists("package.json") {
			util.RunCmd([]string{"npm", "init", "-y"})
		}
		cmd := []string{"npm", "install"}
		if skipAudit {
			cmd = append(cmd, "--no-audit")
		}
		for name, coords := range pkgs {
			name := string(name)
			if found, ok := moduleToNpmjsPackageAliases[name]; ok {
				delete(pkgs, api.PkgName(name))
				name = found
				coords.Name = found
				pkgs[api.PkgName(name)] = coords
			}
			arg := name
			if coords.Spec != "" {
				arg += "@" + string(coords.Spec)
			}
			cmd = append(cmd, arg)
		}
		util.RunCmd(cmd)
	},
	Remove: func(ctx context.Context, pkgs map[api.PkgName]bool) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "npm uninstall")
		defer span.Finish()
		cmd := []string{"npm", "uninstall"}
		for name := range pkgs {
			cmd = append(cmd, string(name))
		}
		util.RunCmd(cmd)
	},
	Lock: func(ctx context.Context) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "npm install")
		defer span.Finish()
		util.RunCmd([]string{"npm", "install"})
	},
	Install: func(ctx context.Context) {
		//nolint:ineffassign,wastedassign,staticcheck
		span, ctx := tracer.StartSpanFromContext(ctx, "npm ci")
		defer span.Finish()
		util.RunCmd([]string{"npm", "ci"})
	},
	ListSpecfile: nodejsListSpecfile,
	ListLockfile: func() map[api.PkgName]api.PkgVersion {
		contentsB, err := os.ReadFile("package-lock.json")
		if err != nil {
			util.DieIO("package-lock.json: %s", err)
		}
		var cfg packageLockJSON
		if err := json.Unmarshal(contentsB, &cfg); err != nil {
			util.DieProtocol("package-lock.json: %s", err)
		}
		pkgs := map[api.PkgName]api.PkgVersion{}
		if cfg.LockfileVersion <= 2 {
			for nameStr, data := range cfg.Dependencies {
				pkgs[api.PkgName(nameStr)] = api.PkgVersion(data.Version)
			}
		} else {
			for pathStr, data := range cfg.Packages {
				nameStr := strings.TrimPrefix(pathStr, "node_modules/")
				pkgs[api.PkgName(nameStr)] = api.PkgVersion(data.Version)
			}
		}
		return pkgs
	},
	// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
	GuessRegexps:                       nodejsGuessRegexps,
	Guess:                              nodejsGuess,
	InstallReplitNixSystemDependencies: nix.DefaultInstallReplitNixSystemDependencies,
	InstallDotReplitSystemDependencies: nix.DefaultInstallDotReplitSystemDependencies,
}

// BunBackend is a UPM backend for Node.js that uses [Bun](https://bun.sh/).
func makeBunBackend() api.LanguageBackend {
	bunVersion := func() []byte {
		return util.SilenceSubroutines(func() any {
			return util.GetCmdOutput([]string{"bun", "--version"})
		}).([]byte)
	}
	newBun := bunIsAvailable() && strings.HasPrefix(string(bunVersion()), "1.2")
	if newBun && commonIsActive("bun.lockb") {
		fmt.Fprintln(os.Stderr, "Bun's lockfile format has changed from bun.lockb to bun.lock.")
		util.RunCmd([]string{"bun", "install", "--save-text-lockfile", "--frozen-lockfile", "--lockfile-only"})
		fmt.Fprintln(os.Stderr, "Lock file upgraded.")
	}
	lockfile := "bun.lock"
	if !newBun {
		lockfile = "bun.lockb"
	}

	b := api.LanguageBackend{
		Name:        "bun",
		Specfile:    "package.json",
		Lockfile:    lockfile,
		IsAvailable: bunIsAvailable,
		IsActive: func() bool {
			return commonIsActive(lockfile)
		},
		FilenamePatterns: nodejsPatterns,
		Quirks: api.QuirksAddRemoveAlsoLocks |
			api.QuirksAddRemoveAlsoInstalls |
			api.QuirksLockAlsoInstalls,
		GetPackageDir: func() string {
			return "node_modules"
		},
		Search: nodejsSearch,
		Info:   nodejsInfo,
		Add: func(ctx context.Context, pkgs map[api.PkgName]api.PkgCoordinates, projectName string, skipAudit bool) {
			//nolint:ineffassign,wastedassign,staticcheck
			span, ctx := tracer.StartSpanFromContext(ctx, "bun (init) add")
			defer span.Finish()

			if !util.Exists("package.json") {
				util.RunCmd([]string{"bun", "init", "-y"})
			}
			cmd := []string{"bun", "add"}
			for name, coords := range pkgs {
				name := string(name)
				if found, ok := moduleToNpmjsPackageAliases[name]; ok {
					delete(pkgs, api.PkgName(name))
					name = found
					coords.Name = found
					pkgs[api.PkgName(name)] = coords
				}
				arg := name
				if coords.Spec != "" {
					arg += "@" + string(coords.Spec)
				}
				cmd = append(cmd, arg)
			}
			util.RunCmd(cmd)
		},
		Remove: func(ctx context.Context, pkgs map[api.PkgName]bool) {
			//nolint:ineffassign,wastedassign,staticcheck
			span, ctx := tracer.StartSpanFromContext(ctx, "bun remove")
			defer span.Finish()

			cmd := []string{"bun", "remove"}
			for name := range pkgs {
				cmd = append(cmd, string(name))
			}
			util.RunCmd(cmd)
		},
		Lock: func(ctx context.Context) {
			//nolint:ineffassign,wastedassign,staticcheck
			span, ctx := tracer.StartSpanFromContext(ctx, "bun install")
			defer span.Finish()
			util.RunCmd([]string{"bun", "install"})
		},
		Install: func(ctx context.Context) {
			//nolint:ineffassign,wastedassign,staticcheck
			span, ctx := tracer.StartSpanFromContext(ctx, "bun install")
			defer span.Finish()
			util.RunCmd([]string{"bun", "install"})
		},
		ListSpecfile: nodejsListSpecfile,
		ListLockfile: func() map[api.PkgName]api.PkgVersion {
			hashString, err := exec.Command("bun", "pm", "hash-string").Output()
			if err != nil {
				util.DieSubprocess("bun pm hash-string: %s", err)
			}

			r := regexp.MustCompile(`(?m)^(@?[^@ \n]+)@(.+)$`)
			pkgs := map[api.PkgName]api.PkgVersion{}

			for _, match := range r.FindAllStringSubmatch(string(hashString), -1) {
				name := api.PkgName(match[1])
				pkgs[name] = api.PkgVersion(match[2])
			}

			return pkgs
		},
		// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
		GuessRegexps:                       nodejsGuessRegexps,
		Guess:                              nodejsGuess,
		InstallReplitNixSystemDependencies: nix.DefaultInstallReplitNixSystemDependencies,
		InstallDotReplitSystemDependencies: nix.DefaultInstallDotReplitSystemDependencies,
	}

	return b
}

var BunBackend = makeBunBackend()
